Skip to content

BUG: Release the Python GIL during pipeline updates - #7002

Open
sdiebolt wants to merge 3 commits into
InsightSoftwareConsortium:mainfrom
sdiebolt:fix/7000-python-update-gil
Open

sdiebolt wants to merge 3 commits into
InsightSoftwareConsortium:mainfrom
sdiebolt:fix/7000-python-update-gil

Conversation

@sdiebolt

@sdiebolt sdiebolt commented Oct 23, 2026 •

Copy link
Copy Markdown

Let Python threads run during ITK pipeline updates, and make the regression test check filtering rather than setup. Closes #7000.

Why the GIL stayed locked

The 2019 GIL-release change enabled SWIG threading but also set nothreadallow, citing this SWIG discussion. That advice served a different goal: protecting director callbacks while deliberately avoiding GIL release around native calls. The combination therefore did not provide the concurrency described by the commit.

The later -threads addition in #5776/#6022 left that override in place. Its regression test accepted overlapping setup/filtering intervals as evidence of GIL release. Measured intervals were about 0.81 seconds, but only 0.002–0.003 seconds were inside Update().

What this fixes
  • Release the GIL for Update() and UpdateLargestPossibleRegion(), retaining it for unrelated wrappers and respecting ITK_PYTHON_RELEASE_GIL.
  • Reacquire the GIL in PyImageFilter callbacks and before PyCommand validates observer callables.
  • Replace the overlap test with a waiting-thread probe around preconstructed filters, suppressing periodic Python thread switching.
  • Exercise native Update() exceptions instead of errors that could arise in Python-side file-reading setup.
  • Document the scope and select an available wrapped type for the Python-filter test.
Validation

Linux x86_64, CPython 3.13.9, NumPy 2.3.4, Release build:

  • Unpatched wrappers: original regression passed; replacement failed.
  • Patched wrappers: replacement passed 20 consecutive runs; all three targeted tests passed.
  • Issue Python Update() appears to hold the GIL despite GIL-release support #7000's ticker workload: 913–927 ticks during 0.97–0.98 seconds of filtering.
  • Compiled SWIG probes confirmed enabled/disabled threading behavior; local hooks passed.
  • An instrumented observer probe detected PyCallable_Check() without the GIL before the PyCommand fix; the probe and full safety script pass after it.

The initial Linux CI run also passed all three targeted tests. Its separate median type-hint failure is described in the PR comments.

Full local suite, Python 3.14 wheel, and full GIL-disabled native build validation were not run. CI must validate the latest revision.

PR Checklist
  • No API changes were made (or the changes have been approved)
  • No major design changes were made (or the changes have been approved)
  • Added test (or behavior not changed)
  • Updated API documentation (or API not changed)
  • Added license to new files (if any) — no new files
  • Added Python wrapping to new files (if any) as described in ITK Software Guide Section 9.5 — no new classes
  • Added ITK examples for all new major features (if any) — bug fix, no new major feature
AI assistance

AI assistance supported diagnosis, implementation, and local testing.

The global SWIG nothreadallow feature overrides threading support.
Limit GIL release to pipeline updates and reacquire it for callbacks.
Replace setup-time overlap detection with a native execution check.

Fixes InsightSoftwareConsortium#7000
@github-actions github-actions Bot added type:Bug Inconsistencies or issues which will cause an incorrect result under some or all circumstances type:Infrastructure Infrastructure/ecosystem related changes, such as CMake or buildbots area:Python wrapping Python bindings for a class type:Testing Ensure that the purpose of a class is met/the results on a wide set of test cases are correct area:Core Issues affecting the Core module area:Documentation Issues affecting the Documentation module labels Oct 23, 2026
@sdiebolt
sdiebolt marked this pull request as ready for review October 24, 2026 00:51
@greptile-apps

greptile-apps Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 4/5

[High impact] The change affects pipeline updates across ITK’s Python wrappers.

Fix the unchecked float requirement in PyImageFilterTest before merging.

Findings

  1. P1 Float-disabled builds fail this test ▶

Summary

The PR enables GIL release for Update() and UpdateLargestPossibleRegion() and adds GIL guards around Python filter callbacks.

  • ITK pipeline updates let other Python threads run.
  • Python filter callbacks run with the GIL held.

Diagram

sequenceDiagram
  participant P as Python thread
  participant W as Update wrapper
  participant F as Native pipeline
  participant C as Python callback
  P->>W: Update()
  W->>W: Release GIL when enabled
  W->>F: Run pipeline
  F->>C: Acquire GIL and call Python
  C-->>F: Return and release acquired GIL
  F-->>W: Finish update
  W->>W: Reacquire GIL
  W-->>P: Return or raise
Loading

Reviews (1) · Last reviewed commit: "BUG: Release the Python GIL during pipel..." · Reviewed by Greptile

Comment thread Wrapping/Generators/Python/Tests/PyImageFilterTest.py Outdated
Custom wrapping builds may disable float while retaining other scalar
types. Exercise an available two-dimensional specialization instead.
@sdiebolt

sdiebolt commented Oct 24, 2026 •

Copy link
Copy Markdown
Author

The Linux failure looks separate from this fix: filtering succeeds, then the median test’s type-hint check raises NameError: name 'xr' is not defined. The unchanged parent-commit type alias reproduces it when xarray is available.

All three GIL-related tests passed in that job. The float-wrapping review finding is fixed in 2d8272e; those tests also pass locally.

Observers can execute while pipeline updates have released the GIL.
Protect callable validation as well as invocation and error handling.

@hjmjohnson hjmjohnson left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sdiebolt Thanks, this is a careful fix, and I reproduced it locally (results below). The setswitchinterval probe is a real improvement: at the merge base the old overlap test passes while your new test fails with Update did not release the GIL.

Requested before merge:

  1. Squash 2d8272e742c and d0a09029620 into 0f6c5a10a70. Both fix something the first commit introduced: it switches PyImageFilterTest.py to itk.F, and releases the GIL in Update() while PyCommand still calls PyCallable_Check without it. One commit keeps every revision bisectable.

  2. Two comments still describe the wider release (on lines this PR doesn't touch, so noted here):

    • Wrapping/Generators/Python/CMakeLists.txt:339: "release the GIL during ITK operations"
    • Wrapping/Generators/Python/Tests/test_gil_release_safety.py:27: "SWIG wraps C++ calls in SaveThread/RestoreThread"

    Only Update and UpdateLargestPossibleRegion release it now.

  3. The GIL tests hard-code itk.F (inline). Either guard them with ITK_WRAP_float in CMake or select a wrapped type.

  4. The unset-callable PyCommand block is an error-path test, not a regression test for the GIL fix (inline).

The END_BLOCK and duplicated-callback notes inline are optional cleanup.

Local verification (macOS arm64, CPython 3.13.9, NumPy 2.3.4, SWIG 4.4.1, abi3)

Minimal wrapped Release build (ITKSmoothing, ITKBridgeNumPy, ITK_PYTHON_RELEASE_GIL=ON), running this PR's three test files against each revision:

Revision test_gil_release.py test_gil_release_safety.py PyImageFilterTest.py
merge base 5e1bd43 FAIL: Update did not release the GIL (the old overlap test passes here) 16/16 pass
2d8272e742c (before the PyCommand fix) pass 16/16 pass
d0a09029620 (head) pass, 20/20 repeats on a loaded host 16/16 pass

Not run: a build with ITK_WRAP_float=OFF, and a build with ITK_PYTHON_RELEASE_GIL=OFF.

Checked against the code
  • igenerator.py:1565-1570 drops threads="1", keeps the global %feature("nothreadallow"), and adds %threadallow Update; and %threadallow UpdateLargestPossibleRegion;. That answers #7000's question about nothreadallow interacting with -threads.
  • ITK_PYTHON_RELEASE_GIL=OFF now really turns the release off.
  • C++ exceptions retake the GIL before SWIG converts them to Python errors: SWIG's thread-allow object lives inside $action, within the try in pyBase.i.
  • itk.imread, itk.imwrite, and the functional filter calls all go through the patched methods.

Not requested here: UpdateOutputInformation, ImageFileWriter::Write(), and optimizer StartOptimization() still hold the GIL, which matches #7000 and the migration guide. Wider coverage belongs with the thread-safety audit in #6757.

itkExceptionStringMacro("There was an error executing the CommandCallable.");
}
}
SWIG_PYTHON_THREAD_END_BLOCK;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SWIG_PYTHON_THREAD_END_BLOCK lines here (and at 206, 237, 272) are redundant. SWIG_PYTHON_THREAD_BEGIN_BLOCK declares a SWIG_Python_Thread_Block object whose destructor calls end(), and END_BLOCK only calls end() early. Each one is the last statement before the closing brace, so the GIL is released at the same point either way. The throw paths skip END_BLOCK already and rely on the destructor, so dropping all four changes nothing.

These four methods now repeat the same "call, decref, PyErr_Print, throw" sequence. A private helper would remove the duplication and give each caller a single GIL scope:

template <class TInputImage, class TOutputImage>
void
PyImageFilter<TInputImage, TOutputImage>::CallPython(PyObject * callable, PyObject * args) const
{
  PyObject * result = PyObject_Call(callable, args, nullptr);
  SWIG_Py_DECREF(args);
  if (result == nullptr)
  {
    PyErr_Print();
    itkExceptionStringMacro("There was an error executing the CommandCallable.");
  }
  SWIG_Py_DECREF(result);
}

template <class TInputImage, class TOutputImage>
void
PyImageFilter<TInputImage, TOutputImage>::GenerateOutputInformation()
{
  Superclass::GenerateOutputInformation();

  SWIG_PYTHON_THREAD_BEGIN_BLOCK;
  if (PyCallable_Check(this->m_GenerateOutputInformationCallable))
  {
    this->CallPython(this->m_GenerateOutputInformationCallable, PyTuple_Pack(1, this->m_Self));
  }
}

In EnlargeOutputRequestedRegion, decref pyDataObject right after PyTuple_Pack(2, ...) (the tuple holds its own reference), so the throw path doesn't leak it.

Comment on lines +151 to +156
median.AddObserver(itk.StartEvent(), itk.PyCommand.New())
try:
median.Update()
check("unset observer callable rejected", False, "should have raised")
except RuntimeError as error:
check("unset observer callable rejected", "CommandCallable" in str(error))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good error-path check, but it can't detect the defect d0a09029620 fixes. With no callable set, m_Object is NULL, and PyCallable_Check(NULL) returns 0 without touching interpreter state, so the block passes whether or not the GIL is held. I confirmed this locally: at 2d8272e742c (GIL released, before this fix) the safety script passes 16/16, including this block.

I don't think a release-build test can catch a missing GIL before PyCallable_Check; the fix is clearest by reading the code. Could the commit message and PR body describe this block as an error-path test rather than a regression test for the GIL fix?

# itk.imread on a non-existent file throws RuntimeError through the
# SWIG wrapper. With -threads, the GIL is released before the C++ call
# and must be re-acquired before setting the Python exception.
image_type = itk.Image[itk.F, 2]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing in these GIL tests needs float: they run a median filter on a constant image and check an exception for a missing input. Any wrapped scalar pixel type works. make_image() (line 101) and test_gil_release.py:8 also hard-code itk.F, so builds with ITK_WRAP_float=OFF can't run them, even though PyImageFilterTest.py was fixed for the same reason in 2d8272e742c.

Either fix is fine:

  • Register the GIL tests only when float is wrapped, as PythonGeometryProtocolTest already does (Tests/CMakeLists.txt:190, if(ITK_WRAP_float AND WRAP_3)). This is the smallest change.
  • Reuse the pixel-type selection from PyImageFilterTest.py in both GIL test files.

COMMAND
${CMAKE_CURRENT_SOURCE_DIR}/test_gil_release.py
)
if(ITK_PYTHON_RELEASE_GIL)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you take the CMake route for the float dependency, one block can guard both GIL tests. The safety test then needs ITK_WRAP_float only, since it is meant to pass with ITK_PYTHON_RELEASE_GIL on or off:

if(ITK_WRAP_float)
  if(ITK_PYTHON_RELEASE_GIL)
    itk_python_add_test(NAME PythonGILReleaseTest ...)
  endif()
  itk_python_add_test(NAME PythonGILReleaseSafetyTest ...)
endif()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:Core Issues affecting the Core module area:Documentation Issues affecting the Documentation module area:Python wrapping Python bindings for a class type:Bug Inconsistencies or issues which will cause an incorrect result under some or all circumstances type:Infrastructure Infrastructure/ecosystem related changes, such as CMake or buildbots type:Testing Ensure that the purpose of a class is met/the results on a wide set of test cases are correct

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python Update() appears to hold the GIL despite GIL-release support

2 participants